Skip to content

test(sync-service): reproduce FlushTracker stall when a tracked consumer dies without cleanup - #4713

Closed
balegas wants to merge 1 commit into
mainfrom
test/flush-tracker-stall-repro
Closed

test(sync-service): reproduce FlushTracker stall when a tracked consumer dies without cleanup#4713
balegas wants to merge 1 commit into
mainfrom
test/flush-tracker-stall-repro

Conversation

@balegas

@balegas balegas commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Deterministic reproduction of the long-standing "system stops flushing the WAL" stall (replication slot confirmed_flush_lsn stops advancing → unbounded WAL growth on Postgres). This PR adds a test only — no fix. The test passes because it asserts the current (buggy) behavior, documenting the stall.

The stall

ShapeLogCollector's FlushTracker tracks each delivered shape at commit until the consumer reports its storage flush. The global flush boundary is the minimum across incomplete entries, so a single entry that never completes pins confirmed_flush_lsn for the entire stack.

Existing cleanup paths for a dead consumer are:

  1. DOWN detection in ConsumerRegistry.publish/2 — but only when a later event is routed to that same shape.
  2. ShapeCleaner.handle_writer_termination/3 from the consumer's terminate/2 — but terminate never runs on an untrappable :kill (and is a no-op for :normal / :shutdown / {:shutdown, other} reasons).

A consumer killed after its shape is tracked but before it flushes, on a shape whose table then goes quiet, escapes both paths: the FlushTracker entry is permanent and the WAL flush boundary never advances again — even though every live consumer is fully flushed.

The test

FlushTracker stall when tracked consumer dies without cleanup on a quiet shape in shape_log_collector_test.exs:

  • txn 1 (lsn 42) touches two tables; both consumers process it and both shapes are tracked
  • the consumer for other_table is killed with :kill before flushing
  • the surviving consumer flushes everything; boundary advances only to 40 (just below the dead shape's tracked position)
  • txn 2 (lsn 50) touches only test_table, so publish-time DOWN detection never fires; after the surviving consumer flushes it, no boundary update is emitted — the stall
  • root-cause proof: explicitly calling ShapeLogCollector.remove_shape/2 for the dead shape immediately unblocks the boundary to 50

Fix direction (follow-up)

Reconcile FlushTracker entries against consumer liveness in the SLC (monitor consumer pids, or periodically sweep entries whose consumer is dead) instead of relying on publish-time detection and consumer self-reporting. A stall watchdog logging the blocking shape handles + consumer liveness would also make future occurrences diagnosable in production.

Test plan

  • mix test test/electric/replication/shape_log_collector_test.exs — 38 tests, 0 failures

🤖 Generated with Claude Code

…mer dies without cleanup

Reproduces the long-standing "system stops flushing the WAL" stall: a
consumer that dies without running terminate (untrappable :kill) after
its shape is tracked in the FlushTracker leaves a permanently
incomplete entry. Publish-time DOWN detection only fires for events
routed to that same shape, and the ShapeCleaner path requires terminate
to run — so if the shape's table goes quiet, the global flush boundary
is pinned forever and confirmed_flush_lsn never advances for the whole
stack, causing unbounded WAL growth on Postgres.

The test proves the boundary stays pinned while every live consumer is
fully flushed, and that explicitly removing the dead shape from the
ShapeLogCollector immediately unblocks it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Jul 14, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 60.05%. Comparing base (5a298c6) to head (47a6dd2).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #4713   +/-   ##
=======================================
  Coverage   60.04%   60.05%           
=======================================
  Files         397      397           
  Lines       43766    43766           
  Branches    12587    12585    -2     
=======================================
+ Hits        26280    26283    +3     
+ Misses      17405    17402    -3     
  Partials       81       81           
Flag Coverage Δ
packages/agents 72.64% <ø> (ø)
packages/agents-mcp 77.70% <ø> (ø)
packages/agents-mobile 80.67% <ø> (ø)
packages/agents-runtime 83.72% <ø> (-0.02%) ⬇️
packages/agents-server 75.69% <ø> (+0.06%) ⬆️
packages/agents-server-ui 8.32% <ø> (ø)
packages/electric-ax 51.06% <ø> (ø)
packages/experimental 87.73% <ø> (ø)
packages/react-hooks 86.48% <ø> (ø)
packages/start 82.83% <ø> (ø)
packages/typescript-client 91.89% <ø> (ø)
packages/y-electric 56.05% <ø> (ø)
typescript 60.05% <ø> (+<0.01%) ⬆️
unit-tests 60.05% <ø> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@alco

alco commented Jul 20, 2026

Copy link
Copy Markdown
Member

This test has been adapted into #4730, with the failure logic reversed: the adapted test asserts that a missing flush from a consumer doesn't wedge FlushTracker.

@alco alco closed this Jul 20, 2026
alco added a commit that referenced this pull request Jul 22, 2026
…ected consumer lifecycle transitions (#4730)

## Problem

The FlushTracker pins `confirmed_flush_lsn` for the whole stack on the
minimum incomplete flush entry, and cleanup of entries is purely
event-driven: either new traffic routed to the same shape (publish-time
DOWN detection) or an explicit `ShapeLogCollector.remove_shape/2`
arriving via the ShapeCleaner chain. Several real-world paths break both
— a storage error that crashes the consumer *and* aborts its
`terminate/2` before `handle_writer_termination` runs, a removal chain
that dies between the `ShapeStatus` removal and the SLC removal
(unretryable: the retry short-circuits at `{:error, :data_removed}`),
untagged `{:shutdown, x}` exit reasons classified as benign, and a
consumer that stays alive but stops flushing. On a subsequently-quiet
table any of these pins the WAL ack forever, causing unbounded WAL
growth on Postgres. PR #4713 has a minimal reproduction of the stall.

## Solution

The SLC becomes the authority on consumer-death handling for flush
accounting. Invariant: an incomplete FlushTracker entry always has a
live monitor watching the pid responsible for completing it, and a
timestamp bounding how long it may sit without flush progress.

- **Writer monitors**: the SLC monitors every consumer with an
incomplete FlushTracker entry (created when a commit-fragment publish
tracks the shape, released when the entry completes or the shape is
removed). DOWNs are classified by exit reason: crashes unpin the flush
boundary immediately and trigger shape invalidation; deliberate-cleanup
DOWNs (`{:shutdown, :cleanup}`) unpin only; bare `:shutdown`/`:killed`
are assumed to be supervisor teardown and left alone.
- **Stall detection**: incomplete entries carry a `last_progress_at`
timestamp refreshed only by flush notifications. A periodic tick finds
shapes whose entries make no flush progress for
`ELECTRIC_FLUSH_STALL_GRACE_PERIOD` (default 60s) and invalidates them
via the normal ShapeCleaner path, re-arming on each fire so a lost
cleanup chain retries instead of pinning forever. Misclassified DOWNs
self-heal through the same mechanism.
- **Stall challenge-response**: a stalled entry whose writer is still
alive is challenged (`:verify_flush_progress`) before being acted on. A
consumer answers only while legitimately deferring flush notifications
(buffering ahead of PG snapshot info, subquery move-in awaiting splice);
the answer re-arms the entry's grace period so healthy deferral phases
are not misread as stalls. Only a suspect still stalled and silent at
the next check — or a shape whose writer is already dead and
demonitored, which skips the challenge — is invalidated. Consumers stay
silent in steady state: no periodic messaging, and a wedged consumer
can't answer, so the backstop stays armed.
- **Reason normalization**: the benign clause of
`handle_writer_termination` shrinks to exactly
`:normal`/`:killed`/`:shutdown`, so untagged `{:shutdown, x}` reasons
now trigger removal; tagged dependency-materializer shutdowns map to
`stop_and_clean`; suspend additionally requires all txn state flushed
and notified (guaranteeing a suspended consumer never leaves an
incomplete entry behind); `Consumer.stop` escalates a wedged consumer to
a kill after the stop call times out.
- ShapeCleaner re-issues the idempotent SLC removal even when the shape
is already gone from `ShapeStatus`, healing pins left by removal chains
that died partway through.

Telemetry: new `[:electric, :flush_tracker, :writer_down]` and
`[:electric, :flush_tracker, :stall_detected]` events.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Garry Hill <garry@electric-sql.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants